マンガデータの分布を見る#

マンガデータを例に、分布を見るためのデータビジュアライゼーション手法を学びましょう:

量的変数の分布を見る際に直感的にわかりやすいのは、ヒストグラム密度プロットですが、パラメータ設定に注意が必要です。 複数の量的変数の分布を比較する際は、箱ひげ図バイオリンプロットがおすすめです。 リッジラインプロットバイオリンプロットと似ていますが、主に分布の時間的な変遷を見る際に適しています。

なお、データビジュアライゼーション手法の選定に関しては、Claus O. Wilke, Fundamentals of Data Visualization5.2 Distributionsを参考にしました。 ただし、次のものは紙幅の制限から割愛しました:

  • Sina plot :Plotlyで簡易に作図する方法が見当たらず,かつバイオリンプロットでニュアンスをつかめると判断したため

  • Strip plot :比較的さいzデータの可視化に適しており、今回のような数千点以上のデータには適さないと判断したため

  • Quantile-quantile plot :理論的な確率密度分布と標本分布の一致性を見る目的で用いられることが多く、解釈に高度な数理統計学の知識が必要であり、本書のスコープを超えるため

初期設定#

以降では、マンガ・アニメ・ゲームデータを可視化するための初期設定を行います。 紙幅の都合のため、書籍版と一部構成が異なることにご注意ください。

Import#

必要なライブラリをImportします。

Hide code cell content
# warningsモジュールのインポート
import warnings

# データ解析や機械学習のライブラリ使用時の警告を非表示にする目的で警告を無視
# 本書の文脈では、可視化の学習に議論を集中させるために選択した
# ただし、学習以外の場面で、警告を無視する設定は推奨しない
warnings.filterwarnings("ignore")
Hide code cell content
# pathlibモジュールのインポート
# ファイルシステムのパスを扱う
from pathlib import Path

# typingモジュールからの型ヒント関連のインポート
# 関数やクラスの引数・返り値の型を注釈するためのツール
from typing import Any, Dict, List, Optional, Union

# numpy:数値計算ライブラリのインポート
# npという名前で参照可能
import numpy as np

# pandas:データ解析ライブラリのインポート
# pdという名前で参照可能
import pandas as pd

# plotly.expressのインポート
# インタラクティブなグラフ作成のライブラリ
# pxという名前で参照可能
import plotly.express as px

# plotly.figure_factoryのインポート
# 高度なプロットとデータ可視化のためのユーティリティ
# ffという名前で参照可能
import plotly.figure_factory as ff

# plotly.graph_objectsのインポート
# より詳細なグラフ作成機能を利用可能
# goという名前で参照可能
import plotly.graph_objects as go

# plotly.graph_objectsからFigureクラスのインポート
# 型ヒントの利用を主目的とする
from plotly.graph_objects import Figure

# plotly.subplotsからmake_subplotsのインポート
# 複数のサブプロットを含む複合的な図を作成する際に使用
from plotly.subplots import make_subplots

型ヒントについてはこちらを参照ください。

定数#

本Notebookで用いる定数を定義します。 なお、Pythonにおける定数の扱いについては、こちらを参照ください。

Hide code cell content
# マンガデータが保存されているディレクトリのパス
DIR_IN = Path("../../data/cm/input")

# 分析結果の出力先ディレクトリのパス
DIR_OUT = DIR_IN.parent / "output" / Path.cwd().parts[-1] / "dists"
Hide code cell content
# 読み込み対象ファイル名の定義

# マンガ各話に関するファイル
FN_CE = "cm_ce.csv"

# マンガ作品と原作者の対応関係に関するファイル
FN_CC_CRT = "cm_cc_crt.csv"
Hide code cell content
# plotlyの描画設定の定義

# plotlyのグラフ描画用レンダラーの定義
# Jupyter Notebook環境のグラフ表示に適切なものを選択
RENDERER = "plotly_mimetype+notebook"

関数#

本Notebookで用いる関数を定義します。

Hide code cell content
def show_fig(fig: Figure) -> None:
    """
    所定のレンダラーを用いてplotlyの図を表示
    Jupyter Bookなどの環境での正確な表示を目的とする

    Parameters
    ----------
    fig : Figure
        表示対象のplotly図

    Returns
    -------
    None
    """

    # 図の周囲の余白を設定
    # t: 上余白
    # l: 左余白
    # r: 右余白
    # b: 下余白
    fig.update_layout(margin=dict(t=25, l=25, r=25, b=25))

    # 所定のレンダラーで図を表示
    fig.show(renderer=RENDERER)
Hide code cell content
def create_distplot(
    df: pd.DataFrame,
    x: str,
    color: str = None,
    show_hist: bool = False,
    show_rug: bool = False,
    **kwargs: Any
) -> Figure:
    """
    データフレームから密度プロットとヒストグラムを作成する

    Parameters
    ----------
    df : pd.DataFrame
        プロットするデータを含むデータフレーム
    x : str
        密度プロットの描画対象とするカラム名
    color : str, optional
        データを分割する基準とするカラム名、指定しない場合はx列の全データを用いる
    show_hist : bool, optional
        ヒストグラムを表示するか否か、デフォルトはFalse
    show_rug : bool, optional
        ラグプロットを表示するか否か、デフォルトはFalse
    **kwargs
        ff.create_distplotに渡すその他のキーワード引数

    Returns
    -------
    Figure
        作成されたプロットのFigureオブジェクト
    """

    if color:
        # colorカラムの値でデータをグループ分け
        grouped = df.groupby(color)

        # 各グループのxカラムのデータをリストに格納、可視化用に逆順に並び替え
        hist_data = [group[x].values for _, group in grouped][::-1]

        # 各グループの名前(colorカラムの値)をラベルとしてリストに格納、可視化用に逆順に並び替え
        labels = [str(name) for name, _ in grouped][::-1]

        # 密度プロットとヒストグラムを作成
        fig = ff.create_distplot(
            hist_data, labels, show_hist=show_hist, show_rug=show_rug, **kwargs
        )
    else:
        # colorが指定されていない場合はx列の全データを用いる
        hist_data = [df[x].values]

        # 密度プロットを作成(ラベルはxを指定)
        fig = ff.create_distplot(
            hist_data,
            group_labels=[x],
            show_hist=show_hist,
            show_rug=show_rug,
            **kwargs
        )

    # x軸のタイトルをxに変更
    fig.update_xaxes(title=x)

    # y軸のタイトルを"確率密度"に変更
    fig.update_yaxes(title="確率密度")

    # 作成されたプロットを返す
    return fig
Hide code cell content
def create_split_violin_plot(
    df: pd.DataFrame, x: str, y: str, split: str, **kwargs
) -> Figure:
    """
    DataFrameからsplit violin plotを作成する関数

    Parameters
    ----------
    df : pandas.DataFrame
        データを含むDataFrame
    x : str
        X軸に使用するカラム名
    y : str
        Y軸に使用するカラム名
    split : str
        バイオリンを分割する際に使用するカラム名、ブール値である必要がある
    **kwargs : dict
        go.Violinに渡す追加のキーワード引数


    Returns
    -------
    fig : plotly.graph_objects.Figure
        生成されたバイオリンプロットの図
    """

    # 新しい図オブジェクトを作成
    fig = go.Figure()

    # Trueのデータでバイオリンプロットを作成
    # ここでX軸とY軸にデータを割り当て、プロットの色やスタイルを設定
    fig.add_trace(
        go.Violin(
            x=df[x][df[split]],
            y=df[y][df[split]],
            legendgroup="True",  # 凡例グループを設定
            scalegroup="True",  # スケールグループを設定
            name="True",  # 凡例名を設定
            side="negative",  # バイオリンの配置を左側に設定
            line_color="blue",  # ラインの色を青に設定
            points=False,  # ポイントを表示しないように設定
            **kwargs
        )
    )

    # Falseのデータでバイオリンプロットを作成
    # Trueのときと同様にデータを割り当て、プロットの色やスタイルを設定
    fig.add_trace(
        go.Violin(
            x=df[x][~df[split]],
            y=df[y][~df[split]],
            legendgroup="False",  # 凡例グループを設定
            scalegroup="False",  # スケールグループを設定
            name="False",  # 凡例名を設定
            side="positive",  # バイオリンの配置を右側に設定
            line_color="orange",  # ラインの色をオレンジに設定
            points=False,  # ポイントを表示しないように設定
            **kwargs
        )
    )

    # プロットのスケールモードを"count"に設定し、中央値の線を表示
    fig.update_traces(scalemode="count", meanline_visible=True)

    # 図のレイアウトを更新
    # 軸のタイトルと凡例のタイトルを設定し、バイオリン間のギャップとモードを設定
    fig.update_layout(
        xaxis_title=x,  # X軸のタイトルを設定
        yaxis_title=y,  # Y軸のタイトルを設定
        legend_title=split,  # 凡例のタイトルを設定
        violingap=0,  # バイオリン間のギャップを0に設定
        violinmode="overlay",  # バイオリンモードを"overlay"に設定
    )

    return fig
Hide code cell content
def format_cols(df: pd.DataFrame, cols_rename: Dict[str, str]) -> pd.DataFrame:
    """
    指定されたカラムのみをデータフレームから抽出し、カラム名をリネームする関数

    Parameters
    ----------
    df : pd.DataFrame
        入力データフレーム
    cols_rename : Dict[str, str]
        リネームしたいカラム名のマッピング(元のカラム名: 新しいカラム名)

    Returns
    -------
    pd.DataFrame
        カラムが抽出・リネームされたデータフレーム
    """

    # 指定されたカラムのみを抽出し、リネーム
    df = df[cols_rename.keys()].rename(columns=cols_rename)

    return df
Hide code cell content
def save_df_to_csv(df: pd.DataFrame, dir_save: Path, fn_save: str) -> None:
    """
    DataFrameをCSVファイルとして指定されたディレクトリに保存する関数

    Parameters
    ----------
    df : pd.DataFrame
        保存対象となるDataFrame
    dir_save : Path
        出力先ディレクトリのパス
    fn_save : str
        保存するCSVファイルの名前(拡張子は含めない)
    """
    # 出力先ディレクトリが存在しない場合は作成
    dir_save.mkdir(parents=True, exist_ok=True)

    # 出力先のパスを作成
    p_save = dir_save / f"{fn_save}.csv"

    # DataFrameをCSVファイルとして保存する
    df.to_csv(p_save, index=False, encoding="utf-8-sig")

    # 保存完了のメッセージを表示する
    print(f"DataFrame is saved as '{p_save}'.")

可視化例#

まず、可視化対象となるデータを読み込みましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_ce = pd.read_csv(DIR_IN / FN_CE)
df_cc_crt = pd.read_csv(DIR_IN / FN_CC_CRT)
Hide code cell content
# 企画系のマンガを除外するため、5ページ以上のマンガ各話を分析とする
df_ce = df_ce[df_ce["pages"] >= 5].reset_index(drop=True)

ヒストグラム#

マンガデータの量を見るための可視化では、こちら葛飾区亀有公園前派出所がいかに特異な長期連載作品であるか確かめました。 では、長期連載作品とそうでない作品の間に違いはあるのでしょうか?

1970年代以降、マンガは巨大ビジネスとして成長し、メディアミックスと分けて語ることはできなくなりました[修治, 2020]。 このような背景から、アニメ等の他メディアへの波及が起きる前に連載を終えてしまうか否かは、商業的な観点から非常に重要[1]です。

本章では特にマンガ各話の 掲載位置page_start_position)に注目[2]し、長期連載作品とそうでない作品の違いを考えます。 page_start_positionはメディア芸術データベースの提供情報から筆者が独自に加工した指標です。 0に近いほどマンガ雑誌巻号の巻頭付近に掲載され、1に近いほどマンガ雑誌巻号の巻末付近に掲載されていることを示します。 具体的な加工方法はAppendixを参照ください。

一般論として、雑誌巻頭付近に掲載されるマンガ作品ほど、読者の目に触れる機会が多くなります。 よって、マンガ雑誌巻号の売上を左右する 人気マンガ作品ほど巻頭付近に掲載される と考えるのは自然です。 本項ではヒストグラムを用い、「長期連載作品とそうでない作品は、連載開始直後の数話の掲載位置の分布が異なる」という仮説を確認[3]します。 今回、 連載開始直後の数話 に限定した理由は以下です:

  • 全各話を対象とすると、長期連載作品ほど分布に大きな影響を与えてしまう

  • 連載終了直前の数話 は、データから機械的に判断することが困難

  • 筆者の経験則として、連載開始直後の数話の掲載位置は短期打ち切りか否かと関係があるように思える

ヒストグラムHistogram ) とは、量的変数に対して、分布の形状を棒(ビン、bin)の長さで表す可視化手法です。 横軸に変数の 区間 、縦軸にその区間に属するデータの数( 度数 )を取ります。 柱状図 とも呼ばれます。 量的変数の分布を見る際、利用頻度が高い図です。 詳細は6章を参照ください。

まずは全マンガ作品を対象にヒストグラムを作成して全体傾向を掴んだあと、合計話数で場合分けして分布を比較しましょう。

Hide code cell content
# 連載マンガ作品として扱う最小のマンガ各話数を、マンガデータの基礎分析を踏まえ設定
min_nce = 8

# 'ccid'でグループ化し、各ccnameについてユニークなceidの数をカウント
df_cc_nce = df_ce.groupby(["ccid"])["ceid"].nunique().reset_index(name="n_ce")
# n_ceの値がmin_nce以上の行だけを保持
df_cc_nce = df_cc_nce[df_cc_nce["n_ce"] >= min_nce].reset_index(drop=True)
# n_ceの値でデータフレームを昇順にソート
df_cc_nce = df_cc_nce.sort_values("n_ce", ignore_index=True)
Hide code cell content
# df_ceを日付とceidでソートし、各ccidについて最初のmin_nce件のデータを取り出す
df_hist = (
    df_ce.sort_values(["date", "ceid"], ignore_index=True).groupby("ccid").head(min_nce)
)

# df_cc_nceのccid列に含まれるccidの行だけを保持
df_hist = df_hist[df_hist["ccid"].isin(df_cc_nce["ccid"].unique())]

# 可視化用に保持するカラム一覧
cols2rename = {
    "page_start_position": "掲載位置",
    "ceid": "ceid",
    "ccid": "ccid",
    "mcname": "mcname",
    "date": "date",
}
# 可視化用にカラム名を変更
df_hist = format_cols(df_hist, cols2rename)
Hide code cell content
# 可視化対象のDataFrameを確認
df_hist.head()
掲載位置 ceid ccid mcname date
0 0.051724 CE117459 C94272 週刊少年チャンピオン 1970-07-27
1 0.165517 CE117460 C94289 週刊少年チャンピオン 1970-07-27
2 0.231034 CE117461 C94447 週刊少年チャンピオン 1970-07-27
3 0.296552 CE117462 C94949 週刊少年チャンピオン 1970-07-27
4 0.451724 CE117466 C95858 週刊少年チャンピオン 1970-07-27
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_hist, DIR_OUT, "hist")
DataFrame is saved as '../../data/cm/output/02/dists/hist.csv'.
Hide code cell source
# df_histを用いて「掲載位置」に関するヒストグラムを作成し、y軸のタイトルを「各話数」に設定
fig = px.histogram(df_hist, x="掲載位置").update_yaxes(title="各話数")

# ヒストグラムを表示
show_fig(fig)

上図は、マンガ各話の掲載位置の分布を表現したヒストグラムです。 ただし、8話以上掲載されたマンガ作品の、8話目までのマンガ各話を対象に可視化しています。

0付近にピークがあるのは、第1話が巻頭に掲載されることが多いためです。 ヒストグラムを作成して確かめてみましょう。

Hide code cell source
# df_histのうち第一話のみを抽出して「掲載位置」に関するヒストグラムを作成
# y軸のタイトルを「各話数」に設定
fig = px.histogram(df_hist.groupby("ccid").head(1), x="掲載位置").update_yaxes(
    title="各話数"
)

# ヒストグラムを表示
show_fig(fig)

上図は、合計8話以上掲載されたマンガ作品の第1話の掲載位置の分布を示したヒストグラムです。ほとんどの作品が巻頭付近に掲載されていることを確認できました。

話を戻し、可視化対象を連載開始から8話目までとします。 「長期連載作品とそうでない作品は、連載開始直後の数話の掲載位置の分布が異なる」という仮説を確認するため、合計各話数の 四分位値 を用いてマンガ作品を分類しましょう。 四分位値とは、データを小さい順に並べた際に、そのデータを四等分するための値のことです。 主に以下の3つの四分位値があります:

  • 第一四分位数(Q1) :データを小さい順に並べた場合の、下から25%の位置にある値。データの25%がこの値以下であることを意味する

  • 第二四分位数(Q2) :データの中央に位置する値で、データをちょうど半分に分ける点。50%のデータがこの値より小さく、50%がこの値より大きいということを意味する。 中央値 とも呼ばれる

  • 第三四分位数(Q3) :データを上から25%の位置にある値。データの75%がこの値以下になることを意味する

Hide code cell content
# min_nce、四分位数、最大値+1を使ってデータの範囲(threashold)を示すリストqs_ceを作成
# 後に登場するfor文をきれいに書くために、min_nceと、n_ceの最大値+1を追加
ths_ce = (
    [min_nce]  # min_nceをリストの最初に追加
    + list(df_cc_nce["n_ce"].quantile([0.25, 0.5, 0.75]).astype(int))  # 四分位数を追加
    + [df_cc_nce["n_ce"].max() + 1]  # 最大値+1をリストの最後に追加
)

# ccidをグループ名にマップするための辞書を初期化
ccid2gname = {}

# ths_ceリスト内の閾値ペアをループして処理
for i in range(len(ths_ce) - 1):
    # 現在の閾値を下限、次の閾値を上限として設定
    lower = ths_ce[i]
    upper = ths_ce[i + 1]

    # n_ceが現在の閾値範囲内にある行だけを抽出してdf_qに保存
    df_q = df_cc_nce[(df_cc_nce["n_ce"] >= lower) & (df_cc_nce["n_ce"] < upper)]

    # ccidとグループ名をマッピングする辞書を作成
    gname = f"第{i+1}群(合計話数:{lower}-{upper-1}話)"
    ccid2gname.update({ccid: gname for ccid in df_q["ccid"]})
Hide code cell content
# 可視化用に新たなDataFrameを作成
df_hist2 = df_hist.copy()

# df_hist2のccnameに基づきにグループ名をマッピング
df_hist2["gname"] = df_hist2["ccid"].map(ccid2gname)

# gnameとmcnameでdf_hist2をソート
df_hist2 = df_hist2.sort_values(["gname", "mcname"], ignore_index=True)

# 可視化用にカラム名を変更
df_hist2 = df_hist2.rename(columns={"gname": "グループ名"})
Hide code cell content
# 可視化対象のDataFrameを確認
df_hist2.head()
掲載位置 ceid ccid mcname date グループ名
0 0.373239 CE155567 C93019 週刊少年サンデー 1970-08-02 第1群(合計話数:8-16話)
1 0.029316 CE155551 C93060 週刊少年サンデー 1970-08-09 第1群(合計話数:8-16話)
2 0.224756 CE155554 C92231 週刊少年サンデー 1970-08-09 第1群(合計話数:8-16話)
3 0.801303 CE155560 C93019 週刊少年サンデー 1970-08-09 第1群(合計話数:8-16話)
4 0.200637 CE155541 C93060 週刊少年サンデー 1970-08-16 第1群(合計話数:8-16話)
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_hist2, DIR_OUT, "hist2")
DataFrame is saved as '../../data/cm/output/02/dists/hist2.csv'.
Hide code cell source
# df_hist2の「掲載位置」に関するヒストグラムを「グループ」ごとに作成
# y軸のタイトルを「各話数」に設定
fig = px.histogram(
    df_hist2, x="掲載位置", facet_col="グループ名", facet_col_wrap=1, height=600
).update_yaxes(title="各話数")

# ファセット(グループごとのヒストグラム)のタイトルを簡潔にする処理
# デフォルトではタイトルは「グループ=xxx」という形式になっている
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))

# ヒストグラムを表示
show_fig(fig)

上図は、マンガ作品の8話目までの掲載位置の分布を、マンガ作品の合計話数の多さに応じてグループ分けして表現したヒストグラムです。 最上段の第1群が最も合計話数が少ないマンガ作品のグループであり、最下段の第4群が最も合計話数が多いマンガ作品のグループです。

下方に位置する(つまり長期連載した)グループほど、掲載位置の分布が左(つまり巻頭)に寄っていることがわかります。 また、巻頭の獲得回数も、最も合計話数の少ないグループは他のグループの半分程度であることがわかります。 念のため、各グループに属するマンガ作品数とマンガ各話数を集計しておきましょう。

Hide code cell content
# グループ名ごとの、ccnameとceidのユニーク数を集計
df_hist2.groupby(["グループ名"]).agg(
    マンガ作品数=("ccid", "nunique"), マンガ各話数=("ceid", "nunique")
).reset_index()
グループ名 マンガ作品数 マンガ各話数
0 第1群(合計話数:8-16話) 559 4472
1 第2群(合計話数:17-31話) 566 4528
2 第3群(合計話数:32-81話) 594 4752
3 第4群(合計話数:82-1956話) 584 4672

最も連載期間の短い第1群のマンガ作品数が若干少ないものの、ヒストグラム同士を比較できないほど差があるわけではありません。 以上から、「長期連載作品とそうでない作品は、連載開始直後の数話の掲載位置の分布が異なる」という仮説と一定の整合性を持つ結果を得られました。

ここで一点だけ注意すべき点があります。 それは、本書で扱うマンガ各話データは途中で打ち切られている[4]ため、 最も新しいマンガ雑誌感号に連載中だったマンガ作品の合計各話数が過小に評価されている という点です。 例を用いて説明しましょう。

Hide code cell content
# データフレームを日付で降順にソート
df_ce_sorted = df_ce.sort_values("date", ascending=False)

# mcnameごとにグループ化し、最新のmiidを取得
# mcname2miid_latest = {mcname: 最新のmiid}
mcname2miid_latest = df_ce_sorted.groupby("mcname")["miid"].first().to_dict()
Hide code cell content
# 例として、最新の週刊少年ジャンプに掲載されている作品を表示
df_ce[df_ce["miid"] == mcname2miid_latest["週刊少年ジャンプ"]][
    ["date", "ccname", "cename", "page_start_position"]
]
date ccname cename page_start_position
170928 2017-07-31 ONE PIECE 第872話 とろふわ 0.006237
170929 2017-07-31 祝!ONE PIECE 20周年!!尾田さんとの思い出漫画! by しまぶー. NaN 0.126819
170930 2017-07-31 ONE PIECE PARTY CONGRATULATIONS ON 20 INCREDIBLE YEARS!! 0.160083
170931 2017-07-31 僕のヒーローアカデミア No.145 烈怒頼雄斗 2 0.180873
170932 2017-07-31 約束のネバーランド 第47話 昔話 0.222453
170933 2017-07-31 食戟のソーマ 223 フィールドを超えて 0.268191
170934 2017-07-31 Dr.STONE Z=19 200万年の在処 0.309771
170935 2017-07-31 銀魂 第643訓 血と涙 0.351351
170936 2017-07-31 ブラッククローバー ページ 117 二人の空間魔法使い 0.388773
170937 2017-07-31 ROBOT×LASERBEAM 17th round 強敵 0.426195
170938 2017-07-31 鬼滅の刃 第70話 人攫い 0.467775
170939 2017-07-31 クロスアカウント #5 噂×嘘 0.509356
170940 2017-07-31 斉木楠雄のΨ難 第252χ 自慢の粘土Ψ工を披露しよう 0.550936
170941 2017-07-31 ハイキュー!! 第262話 いつだって前のめり 0.584200
170942 2017-07-31 ゆらぎ荘の幽奈さん 71 ラブラブバイト大作戦 0.679834
170943 2017-07-31 シューダン! 6 奮起する浜西 0.721414
170944 2017-07-31 ぼくたちは勉強ができない 問23. 天才たちの花園に[x]は不可欠である 0.762994
170945 2017-07-31 火ノ丸相撲 第153番 未来 0.804574
170946 2017-07-31 青春兵器ナンバーワン mission 37: ROMANCE DAWN 0.846154
170947 2017-07-31 HUNTER×HUNTER No.364 思惑 0.879418
170948 2017-07-31 腹ペコのマリー ペコ 20 恋するファッションショー 0.920998
170949 2017-07-31 磯部磯兵衛物語~浮世はつらいよ~ 第244話 拙者には娘さんが…で候 0.979210

上の表は、本書で扱うデータの中で、最も新しい週刊少年ジャンプの雑誌巻号に掲載されたマンガ作品・各話の一覧です。 例えば、鬼滅の刃は、 2017年8月1日以降の掲載実績が存在しない ため合計話数70話となり、第3群に属します。 しかし実際は 2019年 のアニメ化をきっかけに大人気作となり、のちに紙・電子問わずコミック市場全体を活気づける[修治, 2020]ことになります。 最終的に鬼滅の刃205話まで連載を継続したため、他のグループに属するべきだったのかもしれません。

鬼滅の刃に限らず、 最新時点で連載中だった作品に関しては同様の問題が存在します。 最も単純にこの問題を解決する方法は、 完結済みのマンガ作品のみを集計対象とする ことです。 今回は明示的に連載終了を表す情報が付与されていないため、何らかの方法で連載終了を推定する必要があります。 非常に難しい問題ですが、ここではシンプルに 最新のマンガ雑誌巻号に掲載されたマンガ作品は連載中であり、それ以外は完結済み という仮定[5]をおきます。 つまり、各マンガ雑誌の最新の巻号に掲載されたマンガ作品を全て可視化対象から外せば良いことになります。 紙幅の都合上これ以上の深掘りは行いませんが、興味のある方は試してみましょう。

密度プロット#

合計話数に応じてグループ分けしたヒストグラムで、「長期連載作品とそうでない作品は、連載開始直後の数話の掲載位置の分布が異なる」という仮説と整合性のある結果を得られました。 本項では密度プロットを用いて、具体的にどのように分布が異なるか、明確に比較しやすい可視化を目指します。

密度プロットDensity Plot ) とは、主に量的変数に対して、分布の形状をカーネル密度推定による 曲線 で表現する可視化手法です。 ヒストグラムより滑らかに分布を表現することが可能ですが、あくまでも推定結果であることに注意が必要です。 また、ヒストグラムと異なり、同時に複数の確率分布を比較することが可能です。 今回はこの特長を活かし、異なるグループ間の掲載位置の分布を比較します。 密度プロットに関する詳細は6章を参照ください。

Hide code cell content
# ヒストグラムと同様のデータを利用
df_dist = df_hist2.copy()
Hide code cell content
# 可視化対象のDataFrameを確認
df_dist.head()
掲載位置 ceid ccid mcname date グループ名
0 0.373239 CE155567 C93019 週刊少年サンデー 1970-08-02 第1群(合計話数:8-16話)
1 0.029316 CE155551 C93060 週刊少年サンデー 1970-08-09 第1群(合計話数:8-16話)
2 0.224756 CE155554 C92231 週刊少年サンデー 1970-08-09 第1群(合計話数:8-16話)
3 0.801303 CE155560 C93019 週刊少年サンデー 1970-08-09 第1群(合計話数:8-16話)
4 0.200637 CE155541 C93060 週刊少年サンデー 1970-08-16 第1群(合計話数:8-16話)
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_dist, DIR_OUT, "dist")
DataFrame is saved as '../../data/cm/output/02/dists/dist.csv'.
Hide code cell source
# df_distデータフレームを使用して密度プロットを作成
# "掲載位置"をx軸に、"グループ名"を色分けの基準にしてプロット
# 色はPortlandスタイルで指定
fig = create_distplot(
    df_dist, x="掲載位置", color="グループ名", colors=px.colors.diverging.Portland
)

# グラフのレイアウトを更新
# ホバーモードを"x unified"に設定して、x軸に沿った統一されたホバー情報を表示
# 凡例をグラフの右上に配置(yanchorとxanchorで位置調整)
fig.update_layout(
    hovermode="x unified", legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99)
)

# 作成したグラフを表示
show_fig(fig)

上図は、マンガ作品の最初の8話までの掲載位置を、合計話数のグループごとに表現した密度プロットです。 合計話数が8話以上のマンガ作品を可視化対象にしました。 また、可視化対象のマンガ作品の合計話数の四分位値を閾値としてグループ分けを行いました。

概ね想定通り、掲載位置が巻頭に近い領域(つまり図中左側)においては、最も合計話数の多い第4群が最も確率密度が高く、第3群第2群、そして最も合計話数が少ない第1群のマンガ作品の各話が掲載されたことがわかりました。 一方で掲載位置が巻末に近い領域(つまり図中右側)においては、グループ間の順序関係が完全に逆転します。

この結果、第1群とその他のグループの8話までの掲載位置の分布の違いは大きく、前者は巻末付近、後者は巻頭付近に重点的に掲載されていたことが明らかになりました。

ヒストグラムではファセットを分けて表示していたため直接的な比較が難しかったですが、密度プロットにすることで複数の分布を同時に表示することができるようになりました。

では、マンガ雑誌ごとに傾向の違いがあるのでしょうか?

Hide code cell content
# データフレームからユニークなマンガ雑誌名を取得
mcnames = df_dist["mcname"].unique()

# サブプロットを配置するための行数を計算
rows = len(mcnames)

# y軸の最大値を格納するためのリストを初期化
y_max_values = []
Hide code cell source
# 複数のサブプロットを持つ図を作成。各マンガ雑誌名をサブプロットのタイトルとして設定
# vertical_spacingで縦方向のファセット間の余白を調整
fig = make_subplots(
    rows=rows,
    cols=1,
    vertical_spacing=0.1,
    subplot_titles=mcnames,
)

# マンガ雑誌名の数だけ繰り返し処理
for i, mcname in enumerate(mcnames):
    # 現在のマンガ雑誌名に対応するデータをフィルタリング
    df_mc = df_dist[df_dist["mcname"] == mcname].sort_values(
        "グループ名", ignore_index=True
    )
    # 掲載位置の分布プロットを作成
    distplot = create_distplot(
        df_mc, x="掲載位置", color="グループ名", colors=px.colors.diverging.Portland
    )

    # 各サブプロットのy軸の最大値をリストに追加
    y_max_values.append(np.max([trace.y for trace in distplot.data]))

    # 作成した分布プロットを図に追加、可視化のために逆順でtraceを追加
    for trace in distplot.data[::-1]:
        # 凡例が重複しないよう、i==0のときのみ一つだけ表示
        if i > 0:
            trace.showlegend = False
        fig.add_trace(trace, row=i + 1, col=1)

# 全サブプロットの中で最大のy軸値を計算
y_max = np.max(y_max_values)

# Y軸のラベルを表示し、表示範囲を最大値の1.1倍に調整
fig.update_yaxes(title_text="確率密度", range=[0, y_max * 1.1])
# X軸のラベルを下側のサブプロットのみに表示
fig.update_xaxes(title_text="掲載位置", row=rows, col=1)

# ホバーモードを"x unified"に設定して、x軸に沿った統一されたホバー情報を表示
# 各密度プロットが潰れてしまわないように、heightで高さを調整
fig.update_layout(hovermode="x unified", height=800)

# 作成した図を表示する
show_fig(fig)

上図は、マンガ雑誌別のマンガ作品の最初の8話までの掲載位置を、合計話数のグループごとに表現した密度プロットです。 合計話数が8話以上のマンガ作品を可視化対象にしています。 また、グループ分けは、可視化対象のマンガ作品の合計話数の四分位値を基準に行いました。

全マンガ雑誌を通して、合計話数の大きいグループほど8話までの各話が巻頭付近に多く掲載されていることがわかりました。 週刊少年サンデー週刊少年チャンピオンでは特にそれが顕著にあらわれています。 週刊少年マガジン週刊少年ジャンプは先に上げた2誌ほど大きな差は見られませんが、それでも第1群とそれ以外で明確に8話までの掲載位置の分布が異なるように見えます。

少し脇道にそれますが、週刊少年ジャンプの掲載位置0.1-0.2(巻頭から数えて2-4作品目)付近には分布のくぼみが見られます。 一読者としての感覚にすぎませんが、この位置には雑誌を代表する長期連載中の看板作品が常に掲載されている印象があります。 今回は連載開始から8話目までの掲載位置を可視化したため、 看板作品の定位置がくぼんでしまった のではと想像しています。

また、今回は連載開始から8話までを可視化対象としましたが、話数を変更することで別の結果が見えてくるかもしれません。 興味のある方はmin_nceを変更して遊んでみましょう。

箱ひげ図#

次は、長期連載作品の掲載位置について深掘りしてみましょう。 本項では箱ひげ図を用いて「長期連載作品の中でも掲載位置の分布が異なる」という仮説を確認します。

箱ひげ図Box Plot ) とは、主に量的変数に対して、分布の形状を とそこから伸びる 直線 で表現した可視化手法です。 箱は 四分位数 を表し、直線の長さは 最大値・最小値 を表します。 分布の細かい情報が削ぎ落とされてしまいますが、複数の分布を比較する際は非常に便利です。 後述するバイオリンプロットや、本書では割愛したストリッププロットと組合せて描画されることもあります。 詳細は6章を参照ください。

Hide code cell content
# df_ceのデータをccname(マンガ作品名)ごとにグループ化し、各作品の掲載位置の開始点に関する統計情報を集計
# 'count' はその作品が何回掲載されたか、'mean' は掲載位置の平均値
df_tmp = (
    df_ce.groupby("ccname")["page_start_position"].agg(["count", "mean"]).reset_index()
)

# 集計したデータを 'count'(掲載回数)に基づいて降順にソートし、
# トップ10の作品を抽出(最も多く掲載された作品10個を選ぶ)
df_tmp = df_tmp.sort_values("count", ascending=False).head(10)

# 抽出したトップ10の作品を 'mean'(平均掲載位置)に基づいて昇順にソート
# 平均掲載位置が低い(前の方に掲載される)作品が上位に来る
ccnames = df_tmp.sort_values("mean")["ccname"].to_list()

# 元のdf_ceデータフレームから、トップ10の作品名(ccname)に該当するデータのみを抽出
# reset_index(drop=True)は、新しいデータフレームのインデックスをリセットして整理するためのもの
df_box = df_ce[df_ce["ccname"].isin(ccnames)].reset_index(drop=True)

# 抽出したデータフレーム(df_box)のccname列をカテゴリー型に変換し、
# カテゴリーの順序を先ほどソートしたccnamesの順に設定
# これにより、データフレームの並び替えがこの順序に基づくようになる
df_box["ccname"] = pd.Categorical(df_box["ccname"], categories=ccnames, ordered=True)

# df_boxをccnameに基づいてソート(カテゴリー型なので、設定した順序に従ってソートされる)
# ignore_index=Trueは、ソート後のインデックスもリセットして整理するためのもの
df_box = df_box.sort_values("ccname", ignore_index=True)
Hide code cell content
# 可視化用に保持するカラム
cols2rename = {
    "ccname": "マンガ作品名",
    "page_start_position": "掲載位置",
    "ceid": "ceid",
    "date": "date",
}
# 可視化用に列名を変更
df_box = format_cols(df_box, cols2rename)
Hide code cell content
# 可視化対象のDataFrameを確認
df_box.head()
マンガ作品名 掲載位置 ceid date
0 ドカベン 0.213768 CE113217 1976-07-19
1 ドカベン 0.141304 CE112518 1977-07-11
2 ドカベン 0.097473 CE112532 1977-07-04
3 ドカベン 0.141304 CE112548 1977-06-27
4 ドカベン 0.010830 CE112561 1977-06-20
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_box, DIR_OUT, "box")
DataFrame is saved as '../../data/cm/output/02/dists/box.csv'.
Hide code cell source
# 'df_box' データフレームを使い、x軸にはマンガ作品名、y軸には各作品の掲載位置を設定
fig = px.box(df_box, x="マンガ作品名", y="掲載位置")

# 描画した箱ひげ図を表示する
show_fig(fig)

上図は、マンガ作品ごとのマンガ各話掲載位置を表現した箱ひげ図です。 本書で扱うデータ中で最もマンガ各話数の多い10作品を抽出し、可視化対象としています。 ドカベンからジョジョの奇妙な冒険に至るまで、マンガ雑誌や時代を越えたマンガ作品が並んでいますが、掲載位置の分布はそれぞれ全く異なることがわかります。 よって、「長期連載作品の中でも掲載位置の分布が異なる」という仮説と整合性のある結果を得られました。

少し横道にそれますが、名探偵コナンの外れ値(図中のドット)が広がっている点が気になります。

Hide code cell content
# '名探偵コナン'というタイトルのマンガに関連するデータをdf_ceから抽出
# そのデータを 'page_start_position'(掲載位置の開始点)に基づいて降順にソート
# ソートされたデータから、特定のカラムのみを選択し、上位10行のみを表示
df_ce[df_ce["ccname"] == "名探偵コナン"].sort_values(
    "page_start_position", ascending=False
)[["miname", "cename", "page_start_position", "pages"]].head(10)
miname cename page_start_position pages
152328 週刊少年サンデー 2011年 表示号数32 FILE 783 菱形と菱形 0.801402 16.0
163865 週刊少年サンデー 2015年 表示号数29 REVIVAL FILE 06 黎明(SSC55巻より) 0.761411 16.0
152261 週刊少年サンデー 2011年 表示号数29 FILE 780 魔法の料理 0.750000 16.0
152928 週刊少年サンデー 2012年 表示号数9 FILE 805 ワタル・ブラザーズ 0.748826 16.0
163695 週刊少年サンデー 2015年 表示号数22 REVIVAL FILE 04 終極(SSC16巻より) 0.721591 18.0
155282 週刊少年サンデー 2014年 表示号数8 FILE 885 凧揚げ大会 0.720085 16.0
154799 週刊少年サンデー 2013年 表示号数39 FILE 870 願いが叶った時に… 0.720085 16.0
163667 週刊少年サンデー 2015年 表示号数21 REVIVAL FILE 03 気配(SSC16巻より) 0.717308 16.0
154928 週刊少年サンデー 2013年 表示号数44 FILE 874 赤き昔日 0.714592 16.0
152677 週刊少年サンデー 2011年 表示号数49 FILE 795 炎へと回帰する運 0.713992 16.0

REVIVAL FILEと題して、過去のマンガ各話を再掲載することがあるようです。

Hide code cell content
# df_ceから 'cename'(エピソード名)に 'REVIVAL FILE' が含まれる行を抽出
# 抽出されたデータから特定のカラムのみを選択
df_ce[df_ce["cename"].str.contains("REVIVAL FILE") > 0][
    ["miname", "ccname", "cename", "page_start_position"]
]
miname ccname cename page_start_position
163600 週刊少年サンデー 2015年 表示号数19 名探偵コナン REVIVAL FILE 01 邂逅(SSC16巻より) 0.449187
163627 週刊少年サンデー 2015年 表示号数20 名探偵コナン REVIVAL FILE 02 消滅(SSC16巻より) 0.439271
163667 週刊少年サンデー 2015年 表示号数21 名探偵コナン REVIVAL FILE 03 気配(SSC16巻より) 0.717308
163695 週刊少年サンデー 2015年 表示号数22 名探偵コナン REVIVAL FILE 04 終極(SSC16巻より) 0.721591
163828 週刊少年サンデー 2015年 表示号数28 名探偵コナン REVIVAL FILE 05 月下(SSC55巻より) 0.469262
163865 週刊少年サンデー 2015年 表示号数29 名探偵コナン REVIVAL FILE 06 黎明(SSC55巻より) 0.761411
163890 週刊少年サンデー 2015年 表示号数30 名探偵コナン REVIVAL FILE 07 白昼(SSC55巻より) 0.697154
163919 週刊少年サンデー 2015年 表示号数31 名探偵コナン REVIVAL FILE 08 落日(SSC55巻より) 0.656379

本書で扱うデータ中では合計8回REVIVAL FILEを掲載しており、そのいずれも掲載位置は巻頭から遠目であることがわかりました。

バイオリンプロット#

箱ひげ図を用いて、「長期連載作品の中でも掲載位置の分布が異なる」という仮説と整合性のある結果を得られました。 本項ではバイオリンプロットを用いて、その具体的な分布形状を可視化することを目指します。

バイオリンプロットViolin Plot ) とは、主に量的変数に対して、分布を滑らかな 曲線 で表現する可視化手法です。 密度プロットを90度回転したものを、複数の変数に対して描画します(縦横が反転することもあります)。 箱ひげ図ほど分布形状の情報を落とさずに、複数の分布を容易に比較できるという利点があります。 箱ひげ図や(本書では割愛した)ストリッププロットと組合せて描画されることもあります。 詳細は6章を参照ください。

Hide code cell content
# 箱ひげ図と同じデータを利用
df_violin = df_box.copy()
Hide code cell content
# 可視化対象のDataFrameを確認
df_violin.head()
マンガ作品名 掲載位置 ceid date
0 ドカベン 0.213768 CE113217 1976-07-19
1 ドカベン 0.141304 CE112518 1977-07-11
2 ドカベン 0.097473 CE112532 1977-07-04
3 ドカベン 0.141304 CE112548 1977-06-27
4 ドカベン 0.010830 CE112561 1977-06-20
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_violin, DIR_OUT, "violin")
DataFrame is saved as '../../data/cm/output/02/dists/violin.csv'.
Hide code cell source
# df_violinデータフレームを使用してバイオリンプロットを描画
# "マンガ作品名"をx軸に、"掲載位置"をy軸に設定し、図の高さを500に設定
fig = px.violin(df_violin, x="マンガ作品名", y="掲載位置", height=500)

# バイオリンプロットの幅を1に設定し、平均線を表示
fig.update_traces(width=1, meanline_visible=True)

# バイオリンプロットを重ねて表示し、バイオリン間の間隔を0に設定
fig.update_layout(violinmode="overlay", violingap=0)

# 作成したバイオリンプロットを表示
show_fig(fig)

上図は、マンガ作品ごとのマンガ各話掲載位置を表現したバイオリンプロットです。 本書で扱うデータ中で最もマンガ各話数の多い10作品を抽出し、可視化対象としています。 ドカベンからジョジョの奇妙な冒険に至るまで、マンガ雑誌や時代を越えたマンガ作品が並んでいますが、掲載位置の分布はそれぞれ全く異なることがわかります。 「長期連載作品の中でも掲載位置の分布が異なる」という仮説と整合性のある結果を再び得られました。

箱ひげ図と比較したバイオリンプロットの長所は、複雑な分布構造を表現できることです。 特に複数のピークを持つような 多峰性のある 分布の場合、箱ひげ図ではその構造を捉えきれません。 上図の例でも、ドカベンONE PIECEBLEACH銀魂、そしてジョジョの奇妙な冒険において複数のピークがあるように見えます。

この多峰性の原因を調べるため、マンガ各話を掲載日に応じて前半と後半に分け、別々に可視化することを考えます。

Hide code cell content
# 新たな可視化のためにdf_violinのコピーを作成
df_violin2 = df_violin.copy()

# ユニークな 'ceid' の数を計算し、新しいカラム 'half_count' に保存
df_violin2["half_count"] = df_violin2.groupby("マンガ作品名")["ceid"].transform(
    lambda x: x.nunique() / 2
)

# データをマンガ作品名とdateでソートしておく
df_violin2 = df_violin2.sort_values(["マンガ作品名", "date"], ignore_index=True)

# cumcountメソッドを用いて各マンガ作品名ごとに話数インデックス(ceno)を振る
df_violin2["ceno"] = df_violin2.groupby("マンガ作品名").cumcount()

# 掲載時期の前半・後半を割り当てる
# まずはデフォルト値として「前半」を割り当てておく
df_violin2["連載前半"] = True
# 話数インデックスが合計話数の半分以上の場合は、掲載時期を「後半」に更新
df_violin2.loc[df_violin2["ceno"] >= df_violin2["half_count"], "連載前半"] = False
Hide code cell content
# 可視化対象のDataFrameを確認
df_violin2.head()
マンガ作品名 掲載位置 ceid date half_count ceno 連載前半
0 ドカベン 0.008451 CE116042 1972-04-24 317.5 0 True
1 ドカベン 0.188406 CE116029 1972-05-01 317.5 1 True
2 ドカベン 0.119565 CE116013 1972-05-08 317.5 2 True
3 ドカベン 0.113333 CE115997 1972-05-15 317.5 3 True
4 ドカベン 0.094156 CE115981 1972-05-22 317.5 4 True
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_violin2, DIR_OUT, "violin2")
DataFrame is saved as '../../data/cm/output/02/dists/violin2.csv'.
Hide code cell source
# df_violinデータフレームを使用してスプリットバイオリンプロットを作成
# "マンガ作品名"をx軸に、"掲載位置"をy軸に設定し、"連載前半"をスプリット基準にしてプロット
# バイオリンの幅を1に設定
fig = create_split_violin_plot(
    df_violin2, x="マンガ作品名", y="掲載位置", split="連載前半", width=1
)

# グラフのレイアウトを更新
# 凡例をグラフの左上に配置(yanchorとxanchorで位置調整)し、グラフの高さを500に設定
fig.update_layout(
    legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01), height=500
)

# 作成したバイオリンプロットを表示
show_fig(fig)

上図は、マンガ作品ごとのマンガ各話掲載位置を表現したバイオリンプロットです。 本書で扱うデータ中で最もマンガ各話数の多い10作品を抽出し、可視化対象としています。 マンガ作品ごとに、掲載時期を基準に各話を前半と後半に分け、前者を左側で紫色で、後者を右側にオレンジ色で描画しています。

ほとんどのマンガ作品において、前半より後半の方が掲載位置が巻末に近づく(左側の紫色の山より右側のオレンジ色の山が上にずれる)ことがわかります。 一部のマンガ作品については、このずれが元のバイオリンプロットの多峰性の原因の一つと言えるでしょう。

リッジラインプロット#

ヒストグラム密度プロットを通して、連載開始から8話目までの掲載位置の分布が、短期連載作品とそれ以外で大きく異なることがわかりました。 では、いったい 何話目から 差が表れ始めるのでしょうか? 根拠となる文献を見つけられなかったので、読者としてのドメイン知識に基づき「短期連載作品とそれ以外の掲載位置の分布は6話目から差が出始める」という仮説を設定します。 本項では分布の時間的な変化を可視化することに適したリッジラインプロット用い、この仮説を確認します。

リッジラインプロットRidgeline Plot ) とは、量的変数に対して、分布を滑らかな 曲線 で表現した可視化手法です。 密度プロット(あるいはバイオリンプロットを90度回転したもの)を縦に並べた、文字通り山脈の稜線のような見た目をしています。 特に動的に変化する分布の推移を表現する際に強力です。 詳細は6章を参照ください。

Hide code cell content
# ヒストグラムで用いたものと同じデータを使用
df_ridge = df_hist2.copy()

まず、8話以上連載した全てのマンガ作品に対して、各話の掲載位置の分布をリッジラインプロットで可視化します。

Hide code cell content
# データをccnameとdateでソートしておく
df_ridge = df_ridge.sort_values(["ccid", "date"], ignore_index=True)

# cumcountメソッドを用いて各マンガ作品名ごとに話数インデックス(ceno)を振る
df_ridge["話数"] = df_ridge.groupby("ccid").cumcount() + 1
Hide code cell content
# 可視化対象のDataFrameを確認
df_ridge.head()
掲載位置 ceid ccid mcname date グループ名 話数
0 0.015291 CE71082 C109295 週刊少年ジャンプ 1980-08-18 第1群(合計話数:8-16話) 1
1 0.198777 CE71068 C109295 週刊少年ジャンプ 1980-08-25 第1群(合計話数:8-16話) 2
2 0.266055 CE71051 C109295 週刊少年ジャンプ 1980-09-01 第1群(合計話数:8-16話) 3
3 0.394495 CE71038 C109295 週刊少年ジャンプ 1980-09-08 第1群(合計話数:8-16話) 4
4 0.266055 CE71019 C109295 週刊少年ジャンプ 1980-09-15 第1群(合計話数:8-16話) 5
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_ridge, DIR_OUT, "ridge")
DataFrame is saved as '../../data/cm/output/02/dists/ridge.csv'.
Hide code cell source
# df_ridgeデータフレームを使ってリッジラインプロットを作成
# "話数"をy軸に、"掲載位置"をx軸に設定し、図の高さを500に設定
# orientation="h"で水平方向のバイオリンプロットを作成
fig = px.violin(
    df_ridge, y="話数", x="掲載位置", orientation="h", points=False, height=500
)

# side="positive"でバイオリンの片側だけを表示し、width=3でバイオリンの幅を設定
fig.update_traces(side="positive", width=3)

# y軸の表示範囲を設定
# 0からdf_ridge内の"話数"の最大値+1までの範囲に設定
fig.update_yaxes(range=[0, df_ridge["話数"].max() + 1])

# 作成したリッジラインプロットを表示
show_fig(fig)

上図は、マンガ作品の1話目から8話目までの掲載位置の分布の推移を表現したリッジラインプロットです。 合計話数が8話以上のマンガ作品を可視化対象にしています。

ほとんどの場合1話目に巻頭掲載されるため、0付近に分布が集中していますが、話数が進むにつれ誌面全体に広がっていくことがわかります。

では、仮説を確認するために、グループごとにリッジラインプロットを作成してみましょう。

Hide code cell source
# ファセットがグループ名順に並ぶように事前にソートしておく
df_ridge = df_ridge.sort_values(["グループ名"], ignore_index=True)

# df_ridgeデータフレームを使ってリッジラインプロットを作成
# "話数"をy軸に、"掲載位置"をx軸に設定し、図の高さを400に設定
# facet_colでグループ名を指定することでグループごとの可視化を実現
# orientation="h"で水平方向のバイオリンプロットを作成
fig = px.violin(
    df_ridge,
    y="話数",
    x="掲載位置",
    orientation="h",
    facet_col="グループ名",
    points=False,
    height=400,
)

# side="positive"でバイオリンの片側だけを表示し、width=3でバイオリンの幅を設定
fig.update_traces(side="positive", width=3)

# y軸の表示範囲を設定
# 0からdf_ridge内の"話数"の最大値+1までの範囲に設定
fig.update_yaxes(range=[0, df_ridge["話数"].max() + 1])

# ファセット(グループごとのリッジラインプロット)のタイトルを簡潔にする処理
# デフォルトではタイトルは「グループ=xxx」という形式になっている
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))

# 作成したリッジラインプロットを表示
show_fig(fig)

上図は、マンガ作品の1話から8話までの掲載位置の分布の推移を、グループごとに表現したリッジラインプロットです。 合計話数が8話以上のマンガ作品を可視化対象にしています。 また、グループ分けは、可視化対象のマンガ作品の合計話数の四分位値を基準に行いました。 右側に位置するファセットほど、長期間連載したマンガ作品の分布を表します。

グループごとの分布の推移は非常にわかりやすいですが、グループ間での比較は難しいです。 密度プロットを縦に並べることで、複数の凡例を重複表示したリッジラインプロットを作成してみましょう。

Hide code cell content
# データフレームからユニークな話数を取得
cenos = sorted(df_ridge["話数"].unique())

# サブプロットを配置するための行数を計算
rows = len(cenos)

# y軸の最大値を格納するためのリストを初期化
y_max_values = []
Hide code cell source
# 複数のサブプロットを持つ図を作成。各話数をサブプロットのタイトルとして設定
# vertical_spacingで縦方向のファセット間の余白を調整
fig = make_subplots(
    rows=rows,
    cols=1,
    vertical_spacing=0.01,
)

# 話数の数だけ繰り返し処理
for i, ceno in enumerate(cenos):
    # 現在の話数に対応するデータをフィルタリング
    df_ceno = df_ridge[df_ridge["話数"] == ceno].sort_values(
        "グループ名", ignore_index=True
    )
    # 掲載位置の分布プロットを作成
    distplot = create_distplot(
        df_ceno, x="掲載位置", color="グループ名", colors=px.colors.diverging.Portland
    )

    # 作成した分布プロットを図に追加、可視化のために逆順でtraceを追加
    for trace in distplot.data[::-1]:
        # 凡例が重複しないよう、i==0のときのみ一つだけ表示
        if i > 0:
            trace.showlegend = False
        fig.add_trace(trace, row=i + 1, col=1)

    # Y軸のラベルとして話数を表示
    fig.update_yaxes(title_text=f"{ceno}話目", row=i + 1)
    # X軸のメモリを表示しないように設定
    fig.update_xaxes(showticklabels=False, row=i + 1)

# X軸のラベルを下側のサブプロットのみに表示
fig.update_xaxes(title_text="掲載位置", showticklabels=True, row=rows)

# ホバーモードを"x unified"に設定して、x軸に沿った統一されたホバー情報を表示
# 各密度プロットが潰れてしまわないように、heightで高さを調整
fig.update_layout(hovermode="x unified", height=800)

# 作成した図を表示する
show_fig(fig)

上図は、マンガ作品の1話から8話までの掲載位置の分布の推移を、グループごとに表現したリッジラインプロットです。 合計話数が8話以上のマンガ作品を可視化対象にしています。 また、グループ分けは、可視化対象のマンガ作品の合計話数の四分位値を基準に行いました。 右側に位置するファセットほど、長期間連載したマンガ作品の分布を表します。 便宜上、Y軸の表示領域はサブプロットごとに異なる ことにご注意ください。

重畳表示することで、グループ間の推移の違いがわかりやすくなりました。 最も連載期間の短い第1群は、若干の違いがあるものの、4話目まで他のグループと同様の分布をしています。 5話目あたりから巻末に重心のある分布になりはじめ、6話目には0.8付近にピークを持つようになります。 よって、図らずも「短期連載作品とそれ以外の掲載位置の分布は6話目から差が出始める」という仮説と整合性のある結果を得られました。

では、マンガ雑誌ごとに違いはあるのでしょうか?紙幅の都合のため、ここでは週刊少年ジャンプのみを対象に同様の可視化を行います。 興味のある方は、他のマンガ雑誌でも可視化してみましょう。

Hide code cell content
# 週刊少年ジャンプのマンガ作品のみを抽出
df_ridge_jump = df_ridge[df_ridge["mcname"] == "週刊少年ジャンプ"].reset_index(
    drop=True
)
Hide code cell content
# 可視化対象のDataFrameを確認
df_ridge_jump.head()
掲載位置 ceid ccid mcname date グループ名 話数
0 0.015291 CE71082 C109295 週刊少年ジャンプ 1980-08-18 第1群(合計話数:8-16話) 1
1 0.629393 CE74085 C89161 週刊少年ジャンプ 1976-08-16 第1群(合計話数:8-16話) 8
2 0.808307 CE74103 C89161 週刊少年ジャンプ 1976-08-09 第1群(合計話数:8-16話) 7
3 0.565495 CE74115 C89161 週刊少年ジャンプ 1976-08-02 第1群(合計話数:8-16話) 6
4 0.719870 CE74134 C89161 週刊少年ジャンプ 1976-07-26 第1群(合計話数:8-16話) 5
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_ridge_jump, DIR_OUT, "ridge_jump")
DataFrame is saved as '../../data/cm/output/02/dists/ridge_jump.csv'.
Hide code cell source
# 複数のサブプロットを持つ図を作成。各話数をサブプロットのタイトルとして設定
# vertical_spacingで縦方向のファセット間の余白を調整
fig = make_subplots(
    rows=rows,
    cols=1,
    vertical_spacing=0.01,
)

# 話数の数だけ繰り返し処理
for i, ceno in enumerate(cenos):
    # 現在の話数に対応するデータをフィルタリング
    df_ceno = df_ridge_jump[df_ridge_jump["話数"] == ceno].sort_values(
        "グループ名", ignore_index=True
    )
    # 掲載位置の分布プロットを作成
    distplot = create_distplot(
        df_ceno, x="掲載位置", color="グループ名", colors=px.colors.diverging.Portland
    )

    # 作成した分布プロットを図に追加、可視化のために逆順でtraceを追加
    for trace in distplot.data[::-1]:
        # 凡例が重複しないよう、i==0のときのみ一つだけ表示
        if i > 0:
            trace.showlegend = False
        fig.add_trace(trace, row=i + 1, col=1)

    # Y軸のラベルとして話数を表示
    fig.update_yaxes(title_text=f"{ceno}話目", row=i + 1)
    # X軸のメモリを表示しないように設定
    fig.update_xaxes(showticklabels=False, row=i + 1)

# X軸のラベルを下側のサブプロットのみに表示
fig.update_xaxes(title_text="掲載位置", showticklabels=True, row=rows)

# ホバーモードを"x unified"に設定して、x軸に沿った統一されたホバー情報を表示
# 各密度プロットが潰れてしまわないように、heightで高さを調整
fig.update_layout(hovermode="x unified", height=800)

# 作成した図を表示する
show_fig(fig)

上図は、週刊少年ジャンプに掲載されたマンガ作品の1話から8話までの掲載位置の分布の推移を、グループごとに表現したリッジラインプロットです。 合計話数が8話以上のマンガ作品を可視化対象にしています。 また、グループ分けは、可視化対象のマンガ作品の合計話数の四分位値を基準に行いました。 右側に位置するファセットほど、長期間連載したマンガ作品の分布を表します。 便宜上、Y軸の表示領域はサブプロットごとに異なる ことにご注意ください。

4話目までは掲載位置の分布に違いは見られません。 5話目から、最も連載期間の長い第4群のみ他のグループから抜けだし、若干巻頭よりに掲載され始めます。 6話目には、次に連載期間の長い第3群が残り二つのグループから離れ、第4群と同様に巻頭寄りに掲載され始めます。 同時に、第1群は雑誌後半に分布のピークが移動します。 7話目以降も同様です。